iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 14

(Day14) Rust 方法 (Method) 與接收者:語意與生命週期

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師 方法 (Method) 與接收者:語意與生命週期

在 Rust 的世界裡,每一個方法 (method) 的簽名都像一份清晰的合約。

會告訴你方法的名稱和參數,更精準地揭示了這個操作的意圖:它會讀取、修改,還是會「消耗」掉你的資料?

這都藏在 self&self&mut self 這三種「接收者」(receiver) 之中。

我們來了解 Rust 如何透過嚴謹的方法設計,從源頭杜絕意外,引導我們寫出更安全、更可預測的程式碼。

方法簽名就是合約,不是語法糖

這三種接收者是在定義這個 API 的主要行為,向使用者做出了明確的承諾:

  • &self (共享借用):「我只會讀取資料。」 這是一個唯讀操作,保證不會改變物件的狀態,也不會取走其所有權。

  • &mut self (獨占借用):「我需要修改資料。」 這個操作會改變物件的狀態,並要求在修改期間擁有獨占存取權。

  • self (所有權轉移):「我會拿走資料。」 這個操作會消耗掉物件本身,將其所有權轉移出去,或轉換成一個全新的實例。

簡單來說,方法的接收者讓 API 的使用者在呼叫方法之前,就能清楚預期該操作對資料的「副作用與生命週期」影響,從而避免了那些隱藏在程式碼深處的意外。
https://ithelp.ithome.com.tw/upload/images/20250928/201244627xIsEF8Rls.png

生命週期省略:編譯器的常識

Rust 編譯器基於一套「常識性」的規則,為我們進行了自動推斷,不需要手動標註生命週期 (<'a>)。

  • 主要規則: 當方法接收 &self&mut self 並回傳一個引用時,編譯器會預設回傳的引用與 self 具有相同的生命週期。

  • 背後哲學: 這個設計的基礎是「借來的東西,不應該比出借者活得更久」這一安全原則。

只有當編譯器無法從上下文推斷出引用之間的關係時(回傳的引用可能來自多個不同的輸入參數),我們才需要明確標註生命週期,以協助編譯器驗證程式的安全性。

https://ithelp.ithome.com.tw/upload/images/20250928/20124462ebVCx8YEJr.png
https://ithelp.ithome.com.tw/upload/images/20250928/20124462eyRIF6drmn.png

三種接收者,三種 API 設計故事

這三種接收者視為三種不同的 API 設計模式,分別對應我們在之前文章中提到的「讀取、修改、轉移」三種資料命運:

  • 讀取 (&self):提供一個物件的「視圖」(view),讓使用者可以安全地觀察其狀態,而不必擔心資料被改變或移動。

  • 修改 (&mut self):提供一個短暫的「獨占期」,讓使用者可以安全地修改物件狀態,同時編譯器會確保不存在資料競爭的風險。

  • 轉移 (self):代表一個物件生命週期的終結或轉化。它會交出所有權,或將自身拆解、重構成新的值。

選擇哪一種接收者,就是在為您的 API 選擇最恰當的使用情境與心智模型。
https://ithelp.ithome.com.tw/upload/images/20250928/20124462Nxfm8trU8i.png

常見的設計陷阱:如何避免讓 API 的使用者感到意外

清晰的接收者語意有助於我們避開常見的 API 設計反模式:

陷阱一:唯讀操作卻拿走了所有權

  • 情境: 一個名為 get_data() 的方法使用了 self 接收者。

  • 意外: 呼叫者只是想讀取一個值,卻發現自己的變數在呼叫後失效了。

陷阱二:修改操作隱性地複製了資料

  • 情境: 一個修改內部狀態的方法,卻回傳了一個全新的實例,而不是在原地修改。

  • 意外: 產生了不必要的記憶體配置,讓效能成本變得不透明。

陷阱三:回傳一個懸掛引用 (Dangling Reference)

  • 情境: 方法內部創建了一個臨時值,並試圖回傳它的引用。

  • 幸運的是: Rust 編譯器會直接拒絕編譯這種程式碼,從根本上杜絕了懸掛指針的風險。

    // 錯誤:回傳了一個在方法結束時就被銷毀的臨時值的引用
    fn bad_ref(&self) -> &String {
        &String::from("hello") // `String` 是臨時創建的
    }
    

    正確的設計應該是:

    // 方案 1:直接回傳一個帶有所有權的值
    fn good_owned(&self) -> String {
        self.buf.clone() // 或其他邏輯
    }
    
    // 方案 2:回傳一個早已存在的數據的引用
    fn good_ref(&self) -> &str {
        &self.buf
    }
    

陷阱四:為了方便而濫用 'static 生命周期

  • 情境: 當編譯器提示生命週期錯誤時,直接加上 'static 讓它通過。

  • 意外: 這相當於做了一個「這個資料會活到程式結束」的假承諾,通常代表著更深層的設計問題,並可能在未來引發更複雜的編譯錯誤。

實戰範例:一個 Text 結構的 API 設計

讓我們透過一個簡單的 Text 結構,來看看這些原則如何應用:

struct Text {
    buf: String,
}

impl Text {
    // 【讀取】 &self:提供一個零成本的字串視圖 (&str)
    // 生命週期被自動推斷為與 &self 相同
    fn as_str(&self) -> &str {
        &self.buf
    }

    // 【修改】 &mut self:在原地修改內部緩衝區
    fn push(&mut self, s: &str) {
        self.buf.push_str(s);
    }

    // 【轉移】 self:消耗掉 Text 物件,並回傳一個新的 String
    fn into_uppercase(self) -> String {
        self.buf.to_uppercase()
    }
}

方法鏈的設計哲學:零成本視圖 vs. 顯性所有權轉移

在設計鏈式呼叫 (method chaining) 時,Rust 的接收者語意讓成本變得透明:

impl Text {
    // 非破壞性操作:回傳一個借用的視圖,可以持續鏈式呼叫
    // trim() 回傳的是 &str,生命週期仍然綁定於 self
    fn trim_view(&self) -> &str {
        self.buf.trim()
    }

    // 破壞性操作:消耗 self,並回傳一個擁有所有權的新 String
    // 這裡的 to_string() 是一個顯性的成本支出點
    fn into_trimmed(self) -> String {
        self.buf.trim().to_string()
    }
}

使用者可以根據需求,自由選擇要一個零拷貝的「視圖」,還是要一個經過轉換、擁有完整所有權的「新值」。

API 的成本模型一目了然。

與其他語言的比較

  • OOP 語言常把副作用藏在方法裡;Rust 強迫用接收者把副作用顯式化:

    // Java:無法從簽名看出此方法會修改內部狀態
    list.add(item); // 修改了 list,但簽名沒有表明
    
    // Rust:明確表示此方法會修改自身
    // 必須用 `mut` 關鍵字,明確表示這個變數是「可變的」 
    let mut list = Vec::new();
    list.push(item); // 
    
  • 在 GC 世界「鏈式 API」容易默默分配與複製;Rust 把分配放在拿走所有權的那步,讓你看見成本:

    // JavaScript:每次方法調用可能產生新對象,但成本不可見
    const result = str.trim().toUpperCase().split(" ");
    
    // Rust:明確區分借用鏈與所有權轉移
    // 借用鏈:零成本視圖操作
    let view = text.as_str().trim();
    
    // 所有權轉移:明確付費點
    let owned = text.into_trimmed().to_uppercase();
    

API 設計的決策清單

在您設計自己的方法時,可以參考以下清單:

  1. 這個方法只是讀取資料嗎?

    • 是 → 接收 &self,盡可能回傳引用( &str&[T])。
  2. 這個方法需要修改資料嗎?

    • 是 → 接收 &mut self,確保可變借用的範圍盡可能小。
  3. 這個方法是為了轉換資料或轉移所有權嗎?

    • 是 → 接收 self,讓所有權的轉移和成本變得明確。
  4. 需要回傳引用時,它的生命週期從何而來?

    • 優先考慮它是否能與 self 的生命週期綁定。只有在必要時,才手動引入泛型生命週期參數。

結論

方法的簽名 (signature) 就是一份無法違背的合約,直接告訴你它會對你的資料做什麼。

https://ithelp.ithome.com.tw/upload/images/20250928/20124462OzhdBKGu1Y.png

  • &self「我只會讀,不動你的東西。」 (唯讀、共享)

  • &mut self「我要修改,期間別來煩我。」 (修改、獨占)

  • self「這東西現在歸我了。」 (消耗、轉移所有權)

這份合約還可以明確意圖,把因為「我沒想到它會這樣」而產生的問題根除掉。


上一篇
(Day13) Rust 零拷貝:切片 (Slice) 與 字串切片 (&str)
下一篇
(Day15) Rust Trait 泛型與最小承諾:AsRef、Borrow、Into
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言